使用 JS 实现拖放能力所遇到的坑
如何实现一个简单的拖放效果
使用 HTML 的 Drag & Drop API 即可快速实现,基本步骤为:
- 定义可拖拽元素:为允许拖放的 HTML 标签设置
draggable
属性; - 定义拖拽数据:定义拖拽动作中所包含的数据项;
- 定义放置区:为放置区绑定响应事件来响应放置动作。
具体实现步骤,本文不再赘述,可参考 MDN 文档。本文将谈谈,在按照上述步骤实现 D&D (Drag and Drop)组件时所踩到的坑。
拖放冲突
试想一下这个场景:你实现了一个框架,将页面划分为若干模块,每个模块的位置可由用户通过手动拖放来自由调整,实现方法如上所述。但是,某些模块中的业务功能,也有拖放能力,这时外层框架和内层业务功能的拖放可能会出现冲突:外层框架的拖放事件会被内层捕获放置区捕获、内层的拖放事件会被外层框架捕获。而我们期望的是框架的拖放能力仅限于外层,各业务模块内部的拖放能力不超出此模块。因此,需要考虑好拖放隔离。
为每个 D&D 组件划分拖放作用域。例如,上述案例中可划分出作用域:框架域、业务功能域 1、业务功能域 2 ......
划分作用域后,需要在拖拽数据项的数据结构里加上此作用域。例如:
<div
draggable
className="module draggable"
onDragStart={(e) => {
// 添加拖拽数据
e.dataTransfer.setData("text/plain", JSON.stringify({
scope: 'framework', // 表明当前元素仅可在框架作用域进行拖放
data: { moduleName: 'live-preview' }, // 实际要传输的数据
}));
// ......
}}
/>
为可拖拽(draggable)元素及拖拽数据(transfer data)明确了作用域后,还需要在每个可放置元素(droppable)元素的 onDrop
事件的处理函数内,先判断当前拖拽数据是否是来自合适作用域。示例代码如下:
<div
className="module droppable"
onDrop={(e) => {
// 判断作用域
let correctZone = true;
try {
const transferData = e.dataTransfer.getData("text/plain");
const { scope } = JSON.parse(transferData);
correctZone = scope !== 'framework'
} catch {
correctZone = false;
}
if (!correctZone) {
// 当前拖拽数据不是来自框架,可能来自内部业务功能,
// 则不响应此放置事件。
return;
}
// 接以下代码
当然,如果找到了正确的放置区(drop zone),应该阻止 onDrop
事件继续冒泡:
// 接以上代码
else {
e.stopPropagation();
}
// ......
}}
/>
至此拖拽冲突的问题得以解决。
拖拽数据为 JSON 时需要先序列化
DataTransfer 对象用于保存 D&D 过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。
在使用 DataTransfer.setData()
方法用来设置拖放操作的的数据和类型时,如果数据是 JSON 格式,需要先序列化为字符串。
// Wrong:
event.dataTransfer.setData("application/json", { "name": "Mark Sanders" });
// Correct:
event.dataTransfer.setData("text/plain", JSON.stringify({ "name": "Mark Sanders" }));
仅 DragStart 和 Drop 事件能访问拖拽数据
在 D&D 过程中,你是无法通过除 onDragStart
和 onDrop
以外的其它事件来访问 transfer data 的,这或许是出于安全性考虑。浏览器将 D&D 产生的拖拽数据存放在 data store
中,但它的访问权限受事件类型限制,详情如下:
读/写权限: onDragStart
只读: onDrop
受保护: 其它任何事件
<div
className="module droppable"
onDragOver={(e) => {
// NOT working.
console.log(e.dataTransfer.getData("text/plain");
}}
onDrop={(e) => {
// Works fine.
console.log(e.dataTransfer.getData("text/plain");
}}
/>
可放置区必须设置 onDragOver
我们往往会以为,可放置区只要响应 onDrop
事件即可,事实上,还必须同时设置 onDragOver
,并阻止这个事件的其它处理过程。上代码:
<div
className="module droppable"
onDragOver={(e) => {
// 下一行必不可少,否则你会发现 onDrop 事件不会被调用。
ev.preventDefault();
}}
onDrop={(e) => {
ev.preventDefault();
// ......
}}
/>
Tips: 注意每个处理程序调用 preventDefault() 来阻止对这个事件的其它处理过程(如触点事件或指针事件)。
雁过留痕,风过留声